package binanceapi

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/hmac"
	"crypto/sha256"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/c9s/requestgen"
	"github.com/pkg/errors"
	"github.com/prometheus/client_golang/prometheus"

	"github.com/c9s/bbgo/pkg/types"
)

const defaultHTTPTimeout = time.Second * 10
const RestBaseURL = "https://api.binance.com"
const SandboxRestBaseURL = "https://testnet.binance.vision"
const DebugRequestResponse = false

// usedWeightGauge is the prometheus metrics for Binance rate limit usage
var usedWeightGauge = prometheus.NewGaugeVec(
	prometheus.GaugeOpts{
		Name: "binance_used_weight",
		Help: "Binance REST API used weight from response headers (X-MBX-USED-WEIGHT and X-MBX-USED-WEIGHT-(interval)))",
	}, []string{"window", "uri"},
)

// requestCounter counts outgoing Binance REST API requests by HTTP method and URI path.
var requestCounter = prometheus.NewCounterVec(
	prometheus.CounterOpts{
		Name: "binance_request_total",
		Help: "Total number of Binance REST API requests sent, partitioned by HTTP method and request URI path.",
	}, []string{"method", "uri"},
)

// durationHistogram observes the latency (in milliseconds) of Binance REST API requests by HTTP method and URI path.
var durationHistogram = prometheus.NewHistogramVec(
	prometheus.HistogramOpts{
		Name:    "binance_request_duration_milliseconds",
		Help:    "Latency of Binance REST API requests in milliseconds, partitioned by HTTP method and request URI path.",
		// Millisecond buckets: 1ms,2ms,5ms,10ms,25ms,50ms,100ms,250ms,500ms,1s,2.5s,5s,10s
		Buckets: []float64{1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000},
	}, []string{"method", "uri"},
)

var usedWeightWindowRe = regexp.MustCompile(`(?i)^X-MBX-USED-WEIGHT-(\d+)([SMHD])$`)

func init() {
	prometheus.MustRegister(usedWeightGauge, requestCounter, durationHistogram)
}

var errNoApiKey = errors.New("empty api key")

var errNoApiSecret = errors.New("empty api secret")

var dialer = &net.Dialer{
	Timeout:   30 * time.Second,
	KeepAlive: 30 * time.Second,
}

var defaultTransport = &http.Transport{
	Proxy:               http.ProxyFromEnvironment,
	DialContext:         dialer.DialContext,
	MaxIdleConns:        100,
	MaxConnsPerHost:     100,
	MaxIdleConnsPerHost: 100,
	// TLSNextProto:          make(map[string]func(string, *tls.Conn) http.RoundTripper),
	ExpectContinueTimeout: 0,
	ForceAttemptHTTP2:     true,
	TLSClientConfig:       &tls.Config{},
}

var DefaultHttpClient = &http.Client{
	Timeout:   defaultHTTPTimeout,
	Transport: defaultTransport,
}

type RestClient struct {
	requestgen.BaseAPIClient

	Key, Secret string
	PrivateKey  ed25519.PrivateKey

	recvWindow int
	timeOffset int64
}

func NewClient(baseURL string) *RestClient {
	if len(baseURL) == 0 {
		baseURL = RestBaseURL
	}

	u, err := url.Parse(baseURL)
	if err != nil {
		panic(err)
	}

	client := &RestClient{
		BaseAPIClient: requestgen.BaseAPIClient{
			BaseURL:    u,
			HttpClient: DefaultHttpClient,
		},
	}

	// client.AccountService = &AccountService{client: client}
	return client
}

func (c *RestClient) Auth(key, secret string, privateKey ed25519.PrivateKey) {
	c.Key = key
	// pragma: allowlist nextline secret
	c.Secret = secret
	c.PrivateKey = privateKey
}

func (c *RestClient) IsUsingEd25519Auth() bool {
	return len(c.PrivateKey) > 0
}

func (c *RestClient) SetTimeOffsetFromServer(ctx context.Context) error {
	req, err := c.NewRequest(ctx, "GET", "/api/v3/time", nil, nil)
	if err != nil {
		return err
	}

	resp, err := c.SendRequest(req)
	if err != nil {
		return err
	}

	var a struct {
		ServerTime types.MillisecondTimestamp `json:"serverTime"`
	}

	err = resp.DecodeJSON(&a)
	if err != nil {
		return err
	}

	c.timeOffset = currentTimestamp() - a.ServerTime.Time().UnixMilli()
	return nil
}

func (c *RestClient) SendRequest(req *http.Request) (*requestgen.Response, error) {
	// Increment request counter by method and URI path as soon as we are about to send the request.
	var method, uriPath string
	if req != nil && req.URL != nil {
		method = strings.ToUpper(req.Method)
		if method == "" {
			method = "GET"
		}
		uriPath = req.URL.Path
		requestCounter.WithLabelValues(method, uriPath).Inc()
	}

	start := time.Now()
	resp, err := c.BaseAPIClient.SendRequest(req)
	// Observe duration in milliseconds to match metric unit
	ms := float64(time.Since(start).Milliseconds())
	if method != "" && uriPath != "" {
		durationHistogram.WithLabelValues(method, uriPath).Observe(ms)
	}

	if err == nil && resp != nil {
		c.observeUsedWeight(resp)
	}
	return resp, err
}

// observeUsedWeight reads Binance-specific rate limit headers and updates Prometheus gauges.
func (c *RestClient) observeUsedWeight(resp *requestgen.Response) {
	if resp == nil {
		return
	}

	// Iterate all headers to find X-MBX-USED-WEIGHT-(interval) variants as documented
	for k := range resp.Header {
		if m := usedWeightWindowRe.FindStringSubmatch(k); len(m) == 3 {
			intervalNum := m[1]
			intervalLetter := strings.ToLower(m[2])
			label := intervalNum + intervalLetter
			if v := resp.Header.Get(k); v != "" {
				if f, err := strconv.ParseFloat(v, 64); err == nil {
					usedWeightGauge.WithLabelValues(label, resp.Request.RequestURI).Set(f)
				}
			}
		}
	}
}

// NewAuthenticatedRequest creates new http request for authenticated routes.
func (c *RestClient) NewAuthenticatedRequest(
	ctx context.Context, method, refURL string, params url.Values, payload interface{},
) (*http.Request, error) {
	signFunc := c.signHMAC
	if c.IsUsingEd25519Auth() {
		signFunc = c.signEd25519
	}
	return c.newAuthenticatedRequest(ctx, method, refURL, params, payload, signFunc)
}

// newAuthenticatedRequest creates new http request for authenticated routes.
// See the doc links for details:
// - https://developers.binance.com/docs/binance-spot-api-docs/rest-api/endpoint-security-type
// - https://developers.binance.com/docs/binance-spot-api-docs/rest-api/general-information-on-endpoints
func (c *RestClient) newAuthenticatedRequest(
	ctx context.Context, method, refURL string, params url.Values, payload any, signFunc func(string) string,
) (*http.Request, error) {
	if len(c.Key) == 0 {
		return nil, errNoApiKey
	}

	if !c.IsUsingEd25519Auth() {
		// using HMAC authentication
		if len(c.Secret) == 0 {
			return nil, errNoApiSecret
		}
	}

	rel, err := url.Parse(refURL)
	if err != nil {
		return nil, err
	}

	if params == nil {
		params = url.Values{}
	}

	if c.recvWindow > 0 {
		params.Set("recvWindow", strconv.Itoa(c.recvWindow))
	}

	params.Set("timestamp", strconv.FormatInt(currentTimestamp()-c.timeOffset, 10))

	pathURL := c.BaseURL.ResolveReference(rel)
	body, err := castPayload(payload)
	if err != nil {
		return nil, err
	}

	rawQuery := params.Encode()
	toSign := rawQuery + string(body)
	signature := signFunc(toSign)

	// sv is the extra url parameters that we need to attach to the request
	sv := url.Values{}
	sv.Set("signature", signature)
	if rawQuery == "" {
		rawQuery = sv.Encode()
	} else {
		rawQuery = rawQuery + "&" + sv.Encode()
	}

	if rawQuery != "" {
		pathURL.RawQuery = rawQuery
	}

	req, err := http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body))
	if err != nil {
		return nil, err
	}

	// if our payload body is not an empty string
	if len(body) > 0 {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}

	req.Header.Add("Accept", "application/json")

	// Build authentication headers
	req.Header.Add("X-MBX-APIKEY", c.Key)
	return req, nil
}

// signHMAC uses sha256 to sign the payload with the given secret
func (c *RestClient) signHMAC(payload string) string {
	var sig = hmac.New(sha256.New, []byte(c.Secret))
	_, err := sig.Write([]byte(payload))
	if err != nil {
		return ""
	}

	return fmt.Sprintf("%x", sig.Sum(nil))
}

// signEd25519 uses ed25519 to sign the payload with the given private key
func (c *RestClient) signEd25519(payload string) string {
	return GenerateSignatureEd25519(payload, c.PrivateKey)
}

func currentTimestamp() int64 {
	return FormatTimestamp(time.Now())
}

// FormatTimestamp formats a time into Unix timestamp in milliseconds, as requested by Binance.
func FormatTimestamp(t time.Time) int64 {
	return t.UnixNano() / int64(time.Millisecond)
}

func castPayload(payload interface{}) ([]byte, error) {
	if payload != nil {
		switch v := payload.(type) {
		case string:
			return []byte(v), nil

		case []byte:
			return v, nil

		case map[string]interface{}:
			var params = url.Values{}
			for a, b := range v {
				params.Add(a, fmt.Sprintf("%v", b))
			}

			return []byte(params.Encode()), nil

		default:
			body, err := json.Marshal(v)
			return body, err
		}
	}

	return nil, nil
}

type APIResponse struct {
	Code    string          `json:"code"`
	Message string          `json:"msg"`
	Data    json.RawMessage `json:"data"`
}
